浅谈Android自定义Lint规则的实现 (二)

关于Lint的一些基本知识,以及自定义Lint如何实现,可以参考我的系列文章:
Android Lint工作原理剖析
浅谈Android自定义Lint规则的实现 (一)
浅谈Android自定义Lint规则的实现 (二)

相关Demo代码可以参见我的github代码库:
CustomLintDemo

上一篇文章针对Android自定义Lint规则的总体开发流程做了介绍,本文针对java源代码Lint检测方法做细节介绍。由于网上关于自定义Lint规则的文章比较有限,且对于lombok.ast库的相关细节几乎没有文档可用,所以本文内容主要是根据自身开发经验做的总结,难免会有疏漏或错误,还请各位大神批评指正。

检测Java源代码

针对Java源代码做Lint检测,我们需要让自定义的XXXDetector类继承com.android.tools.lint.detector.api.Detector类,并实现com.android.tools.lint.detector.api.Detector.JavaScanner接口,同时在该XXXDetector对应的Issue中定义检测的范围为com.android.tools.lint.detector.api.Scope.JAVA_FILE_SCOPE,如图:

其实我们查看Detector类的源码会发现,JavaScanner是在Detector中定义的内部接口,JavaScanner接口中定义的10个方法,都以完全相同的签名在Detector中重新定义了一遍。所以,Detector相当于是JavaScanner接口的适配器,上图中我们自定义的ActivityFragmentLayoutNameDetector类可以根据需要只实现JavaScanner接口的部分方法,而不需要实现全部10个方法。
作为7个Scanner(这7个Scanner在上一篇中有介绍,这里的JavaScanner就是其中一个)的外部类,Detector实际上是它们共同的适配器。

JavaScanner接口定义的10个方法如下图所示:

从上图可以看到,JavaScanner定义的很多方法都用到了Node类,还出现了ConstructorInvocation、MethodInvocation等类。那么这些类到底代表什么,我们如何在java源代码的分析中使用这些类呢?要搞清楚这个问题,我们首先要介绍一下Abstract Syntax Tree。

Abstract Syntax Tree是什么

在计算机科学中,Abstract Syntax Tree(简称AST)是对程序设计语言写成的源代码的一种树型表示。树中的每一个节点(node)代表在源代码中存在的一个构建体。之说以说语法树是“抽象的”(Abstract)是因为它并没有把真实语法的所有细节都表达出来,比如成对匹配的括号就隐式的用树结构来表达,一条if-condition-then语句可能就用一个具有3个分支的节点来表达。

在Java和Android开发工作中,IDE工具带给我们的很多便利功能都是通过AST来实现的,比如Quick Fix、Quick Assist、修改一个变量名时自动把所有对该变量的引用都同步修改、以及在Android Studio中摁住键的同时点击一个类名会跳转到那个类的定义文件等。

AST与XML文件的DOM模型类似,允许你通过修改树模型来把这些修改反映到Java源代码中。不过我们在自定义Lint使用AST的过程中一般不涉及修改节点。一个AST的例子如下图:

在查看Android Lint源码的过程中可以发现,它涉及到两套AST的实现API,一套是Ecj(Eclipse Java development tools)的,在包org.eclipse.jdt.internal.compiler.ast中;另一套是lombok.ast的。系统暴露给我们允许我们直接用来扩展Lint规则的是lombok.ast的AST API。JavaScanner定义的10个方法中的Node指的就是lombok.ast.Node,而ConstructorInvocation、MethodInvocation都是lombok.ast.Node的子类。

如果你对AST感兴趣,可以查看Eclipse网站的AST介绍文档

JavaScanner接口方法的使用

前面的图片显示了JavaScanner接口定义的10个方法,这些方法有些可以单独使用,有些需要配合使用,这里介绍常见的用法。

【1】getApplicableNodeTypes()需要与createJavaVisitor()配合使用。

getApplicableNodeTypes()返回我们感兴趣的Node列表,然后在createJavaVisitor()返回的AstVisitor中去处理这些Node。
比如,我们想对java源代码中的if、try、for语句进行检测,就可以这样实现getApplicableNodeTypes():

1
2
3
4
@Override
public List<Class<? extends Node>> getApplicableNodeTypes() {
return Arrays.asList(Try.class, If.class, For.class);
}

其中的Try、If、For都是lombok.ast.Node的子类。

然后定义一个AstVisitor的子类,并在createJavaVisitor()中返回它的一个实例,那么在java源码中出现的try、if、for语句对应的node就会触发ForIfTryBlockVisitor中对应的回调函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@Override
public AstVisitor createJavaVisitor(@NonNull JavaContext context) {
return new ForIfTryBlockVisitor(context);
}

private class ForIfTryBlockVisitor extends ForwardingAstVisitor {
private final JavaContext mContext;

public ForIfTryBlockVisitor(JavaContext context) {
mContext = context;
}

@Override
public boolean visitTry(Try node) {
//... 在这里对try语句做你需要的检查

return super.visitTry(node);
}

@Override
public boolean visitFor(For node) {
//... 在这里对for语句做你需要的检查

return super.visitFor(node);
}

@Override
public boolean visitIf(If node) {
//... 在这里对if语句做你需要的检查

return super.visitIf(node);
}

}

【2】getApplicableMethodNames()需要与void visitMethod(JavaContext context, AstVisitor visitor, MethodInvocation node)配合使用,此时createJavaVisitor()根据实际需求可有可无。

这里getApplicableMethodNames()用来返回你感兴趣的那些方法调用列表,这些方法调用对应的node每一次出现都会触发visitMethod(JavaContext context, AstVisitor visitor, MethodInvocation node)方法被回调。

例如,我想针对java源代码中所有调用setContentView()和inflate()的代码进行检查,可以这样定义getApplicableMethodNames():

1
2
3
4
@Override
public List<String> getApplicableMethodNames() {
return Arrays.asList("setContentView", "inflate");
}

然后在visitMethod方法中做具体处理:

1
2
3
4
5
6
7
8
9
10
11
@Override
public void visitMethod(@NonNull JavaContext context, AstVisitor visitor, @NonNull MethodInvocation node) {
String methodName = node.astName().astValue();
if (methodName.equals("setContentView")) {
//在这里做针对setContentView()调用的具体检查

} else if (methodName.equals("inflate")) {
//在这里做针对inflate()调用的具体检查

}
}

【3】getApplicableConstructorTypes()需要与visitConstructor(JavaContext context, AstVisitor visitor,ConstructorInvocation node,ResolvedMethod constructor)配合使用,此时createJavaVisitor()根据实际需求可有可无。

这里getApplicableConstructorTypes()用来返回你感兴趣的构造方法的列表,系统会在符合条件的构造方法的每一次出现都回调一次visitConstructor方法,而传入的node参数就是对应的调用构造函数在AST中的节点。

1
2
3
4
5
6
7
8
9
10
11
12
@Override
public List<String> getApplicableConstructorTypes() {
return Arrays.asList("com.ljfxyj2008.BlankFragment");
}

@Override
public void visitConstructor(@NonNull JavaContext context, AstVisitor visitor, @NonNull ConstructorInvocation node, @NonNull JavaParser.ResolvedMethod constructor) {
//在这里做针对构造函数调用语句的具体检查

System.out.println("===visitConstructor node = " + node
+ "\nlocation = " + context.getLocation(node).getStart().getLine());
}

【4】appliesToResourceRefs()需要与visitResourceReference(JavaContext context,AstVisitor visitor,Node node,String type,String name,boolean isFramework)配合使用,用来对感兴趣的资源文件引用的代码进行检查,比如引用了R.layout.main或者R.string.app_name的代码。

这两个方法的实现步骤与前面的几对类似,就不再贴代码了。

在上面介绍的JavaScanner的所有相关API中,最重要的就是createJavaVisitor()以及该方法返回的AstVisitor。事实上,我们完全可以只利用createJavaVisitor()方法以及对应的AstVisitor,就完成对java源代码的所有检查工作。之所以Lint系统为JavaScanner接口定义了10个方法,仅仅是为了使得对常见的一些处理需求实现起来更加简洁和高效。

lombok.ast API中重要的类

既然Lint分析是处理AST中的节点,那么最重要最常用的类当然就是lombok.ast.Node了。
lombok.ast.Node实际上是一个接口,定义了对于AST节点的一系列通用操作,有多个抽象子类/接口都实现/继承了它。而在这些抽象子类/接口中我们最常用到的是AbstractNode。AbstractNode是实现了lombok.ast.Node接口的抽象类,它有为数众多的子类,这些子类直接与各种语句直接对应,如图:

可以看到常用的case、break、continue、for、if等语句都直接被映射为这里具体的Node子类,而类构造器的声明与调用(即用new关键字来生成一个新对象)也能在这里找到对应的类(即ConstructorDeclaration和ConstructorInvocation)。有了AbstractNode如此丰富的子类,我么对java源码的分析就方便了很多,对于自己想要分析的元素,先找到与它对应的AbstractNode子类,然后在定义自己的AstVisitor时去分析这个对应类即可。

下面给出一个简单的例子,目的是检查用户有没有使用new Message()来获取新的android.os.Message对象,如果有这种调用,我们就抛出一个issue,提示用户应该使用效率更高的handler.obtainMessage或者Message.Obtain()来获取,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
public class MessageObtainDetector extends Detector
implements Detector.JavaScanner {

public static final Issue ISSUE = Issue.create("MessageObtainNotUsed",
"You should not call `new Message()` directly.",
"You should not call `new Message()` directly. Instead, you should use `handler.obtainMessage` or `Message.Obtain()`.",
Category.CORRECTNESS,
9,
Severity.ERROR,
new Implementation(MessageObtainDetector.class,
Scope.JAVA_FILE_SCOPE));

@Override
public List<Class<? extends Node>> getApplicableNodeTypes() {
return Collections.<Class<? extends Node>>singletonList(ConstructorInvocation.class);
}

@Override
public AstVisitor createJavaVisitor(@NonNull JavaContext context) {
return new MessageObtainVisitor(context);
}


private class MessageObtainVisitor extends ForwardingAstVisitor {
private final JavaContext mContext;

public MessageObtainVisitor(JavaContext context) {
mContext = context;
}

@Override
public boolean visitConstructorInvocation(ConstructorInvocation node) {
JavaParser.ResolvedNode resolvedType = mContext.resolve(node.astTypeReference());
JavaParser.ResolvedClass resolvedClass = (JavaParser.ResolvedClass) resolvedType;

if (resolvedClass != null
&& resolvedClass.isSubclassOf("android.os.Message", false)){
mContext.report(ISSUE,
node,
mContext.getLocation(node),
"You should not call `new Message()` directly.");

return true;

}

return super.visitConstructorInvocation(node);
}


}

}

这段代码结构非常简单,在getApplicableNodeTypes()方法中返回一个List表明我们只对ConstructorInvocation.class感兴趣,然后在createJavaVisitor()中返回一个自定义的AstVisitor对象,也就是这里的MessageObtainVisitor。因为是检测对构造方法的调用,所以我们在MessageObtainVisitor的定义中只需要重写visitConstructorInvocation()方法。事实上,即使我们把这里对getApplicableNodeTypes()重写的代码段删除,仍然可以达到检测new Message()的目的,因为只要在MessageObtainVisitor()中重写了visitXXX()就可以保证它被调用,但是我们重写getApplicableNodeTypes()可以确保效率更高。

认真看自定义类MessageObtainVisitor的visitConstructorInvocation()方法体,可以看到这两行代码:

1
2
JavaParser.ResolvedNode resolvedType = mContext.resolve(node.astTypeReference());
JavaParser.ResolvedClass resolvedClass = (JavaParser.ResolvedClass) resolvedType;

这两行代码非常关键,它把lombok.ast中的类转换成了JavaParser中的类,如此一来我们就可以获取与此node对应的类、变量、方法或注解的详细信息。比如这里的node被转换为resolvedClass后,就可以获取与此类有关的类继承关系。

由于lombok.ast中各种回调函数(如getApplicableNodeTypes、visitConstructorInvocation等)的参数都是lombok.ast.Node类型,它们包含的都是与AST(抽象语法树)相关的结构性信息,而这些信息对于我们分析java源码的具体业务来说肯定是远远不够的,所以在合适的时机把node转换成JavaParser中各种合适的类型就非常重要。那么在lombok.ast的各种回调函数中传入的node能够被转换成哪些类型呢?看下图:

可以看到类型还是相当丰富的,足够我们对AST各种节点进行详细分析了。

相信细心的朋友应该发现了,上面MessageObtainDetector这个类的代码中是对ConstructorInvocation.class类型的节点进行了分析,而我们在JavaScanner接口方法的使用这一节的第3条介绍了getApplicableConstructorTypes()和visitConstructor()方法,它们看起来很类似啊?没错,对于MessageObtainDetector类中进行的构造方法调用检查,我们同样可以用getApplicableConstructorTypes()和visitConstructor()来实现,这样就不需要自己去定义一个MessageObtainVisitor了。我们在前面也提到过,JavaScanner中的定义的10个回调方法,其实大部分都是为了简化代码结构与提高执行效率,其实完全可以只用自定义的ForwardingAstVisitor来完成所有检测功能。

还有一组比较有用的类型转换方法上面没有提到:

1
2
ClassDeclaration surroundingClass = JavaContext.findSurroundingClass(node);
Node surroundingMethod = JavaContext.findSurroundingMethod(node);

用这组方法可以获取到此node外围包裹它的类或方法,这是JavaContext类提供的两个静态方法,可以将这两个方法与上面介绍的JavaParser中的ResolvedXXX类型配合使用。

小结

使用Lint来分析java源代码,需要实现JavaScanner中合适的回调函数。这些回调函数大部分是为了使得对常见的一些处理需求实现起来更加简洁和高效,事实上我们完全可以只用自定义的AstVisitor来完成所有Lint检查工作,并在createJavaVisitor()中返回这个自定义AstVisitor的实例。
JavaScanner回调函数的node包含的都是与AST结构相关的信息,如果要对node对应的java类、方法等进行详细的业务分析,就需要把node转换成JavaParser中定义的合适类型。

文章目录
  1. 1. 检测Java源代码
    1. 1.1. Abstract Syntax Tree是什么
    2. 1.2. JavaScanner接口方法的使用
    3. 1.3. lombok.ast API中重要的类
  2. 2. 小结